Skip to main content

Payments Overview

The Patient Portal Payments API lets the authenticated patient list the payments (purchases) raised on their own cases. The endpoint is self-only: the JWT subject is the only patient whose records are returned, scoped to the cases owned by that patient in the calling organization.

Endpoints

#MethodPathPurpose
1GET/api/v1/users/me/paymentsList the patient's case payments (optionally filtered by case)

Related resources: Orders (/me/orders) and Medications (/me/medications).

Authentication

Every endpoint requires a successful /verify-otp exchange first.

HeaderRequiredDescription
cv-api-keyYesTenant API key. Resolves the calling organization. Missing → 400 VALIDATION_ERROR.
AuthorizationYesBearer <accessToken> from POST /api/v1/users/auth/verify-otp. Missing or malformed → 401.

The patientPortalAuth() middleware enforces token type patient-portal, JWT/cv-api-key org-match, and that the user still exists. Any failure is collapsed to 401 VALIDATION_ERROR "Invalid or expired token".

Permission Matrix

ActionAllowed when…
List own paymentsAlways (filtered to the patient's cases in the calling org).
List a specific case's paymentsThe case is owned by the patient (submitterId) and belongs to the calling org. Otherwise → 403.

When caseId is omitted the server resolves the patient's own case ids in the calling organization first; if the patient has no cases, the response data array is [] and nextCursor is null.

Response Envelope

The list is wrapped under payments alongside the pagination cursor:

{
"status": 200,
"success": true,
"data": {
"payments": [ "..." ],
"nextCursor": "<id> | null"
}
}

Error responses follow:

{ "status": 400, "success": false, "error": "<message>", "code": "<CODE>" }

Query Parameters

FieldTypeRequiredNotes
caseIdstring (UUID)NoRestrict the list to a single case owned by the patient. Verified via ensurePatientOwnsCase — if the case does not belong to the patient or to the calling org, returns 403.
limitintegerNo1100. Defaults to 20. Coerced from string.
afterstring (UUID)NoCursor — the last id from the previous page. The server skips that row and returns the next page.

Pagination

Cursor-based over the row id, ordered by createdAt descending (newest first):

  1. Request page 1 without after. The server returns up to limit items plus nextCursor.
  2. If nextCursor is non-null, pass it as after=<nextCursor> to fetch the next page.
  3. When the server has no more rows, nextCursor is null.

The cursor is the last item's id. Internally the server takes limit + 1 rows, drops the extra, and emits its id as the cursor — so a null cursor unambiguously means "no more pages."

Object Shapes

Payment

Returned by GET /me/payments. Backed by CasePayment rows where isDeleted = false.

FieldTypeNotes
idstring (UUID)CasePayment.id.
descriptionstringPayment description (required on creation).
amountnumberCharge amount before any discount.
discountedAmountnumber | nullFinal amount after discounts, if applied.
statusenumOne of PAID, UNPAID, CANCELED, IN_DISPUTE, LOST_DISPUTE, REFUND, ERROR, PENDING_RETRY.
paymentDateISO-8601 datetime | nullWhen the payment settled.
dueDateISO-8601 datetimeInvoice due date.
caseIdstring (UUID)The case this payment belongs to.
createdAtISO-8601 datetimeServer-generated.
fees{ consultFee, convenienceFee, paymentProcessingFee, pharmacyFee, shippingFee } | nullPer-fee breakdown when a CasePaymentFees row exists; otherwise null.

Server-Side Behaviors and Defaults

  • Tenant + ownership scoping. With or without caseId, the result is restricted to cases where submitterId = userId and organizationId = req.patientOrganization.id. There is no cross-tenant or cross-patient surface.
  • caseId is pre-validated. When supplied, ensurePatientOwnsCase runs before the list query; failure short-circuits to 403.
  • Soft-deleted payments excluded. The query filters isDeleted = false.
  • Default limit. 20. Maximum 100. The validator coerces string → number.
  • Cursor semantics. after is the last row's id from the previous page; the server uses cursor: { id: after }, skip: 1 and asks for limit + 1 rows to detect end-of-results.
  • Empty patient. If the patient has no cases in the calling org (and no caseId was supplied), the endpoint returns { payments: [], nextCursor: null } — no error.

Security Properties

  • Tenant isolation. Case-id resolution pins organizationId to the calling org from cv-api-key; the payment query is filtered to the case ids that resolution returns.
  • Ownership isolation. Case-id resolution pins submitterId to the JWT subject; caseId queries additionally pass through ensurePatientOwnsCase.
  • Uniform 403. "Doesn't exist", "not yours", and "wrong tenant" all collapse to the same 403 VALIDATION_ERROR "You do not have access to this case" so case ids cannot be probed.
  • Token type pinned. Only JWTs with type: 'patient-portal' reach the handler.
  • Cross-tenant defense. The JWT's organizationId is verified against the cv-api-key-resolved org on every call.
  • No write surface. The endpoint is read-only — it never creates, captures, or refunds a payment.

Integrator Guidance

  • Refresh proactively. Refresh the access token via /refresh-token before the 15-minute expiry.
  • Listing strategy. Use ?caseId= when surfacing payments within a single-case view; omit it for an account-wide billing list.
  • Paginate forward only. The cursor moves forward through the sort order — there is no before cursor.
  • Show discountedAmount when present. It is the amount actually charged; amount is the pre-discount figure.
  • fees: null is normal — it just means no CasePaymentFees row was written for that payment.
  • Treat 403 as "no access, may or may not exist". Do not display case-id-specific debug text.